import { Datasource, Query } from "@budibase/types"
import {
  DatabaseName,
  datasourceDescribe,
} from "../../../../integrations/tests/utils"
import { MongoClient, type Collection, BSON, Db } from "mongodb"
import { generator } from "@budibase/backend-core/tests"

const expectValidId = expect.stringMatching(/^\w{24}$/)
const expectValidBsonObjectId = expect.any(BSON.ObjectId)

const descriptions = datasourceDescribe({ only: [DatabaseName.MONGODB] })

if (descriptions.length) {
  describe.each(descriptions)(
    "/queries ($dbName)",
    ({ config, dsProvider }) => {
      let collection: string
      let datasource: Datasource

      async function createQuery(query: Partial<Query>): Promise<Query> {
        const defaultQuery: Query = {
          datasourceId: datasource._id!,
          name: "New Query",
          parameters: [],
          fields: {},
          schema: {},
          queryVerb: "read",
          transformer: "return data",
          readable: true,
        }
        const combinedQuery = { ...defaultQuery, ...query }
        if (
          combinedQuery.fields &&
          combinedQuery.fields.extra &&
          !combinedQuery.fields.extra.collection
        ) {
          combinedQuery.fields.extra.collection = collection
        }
        return await config.api.query.save(combinedQuery)
      }

      async function withClient<T>(
        callback: (client: MongoClient) => Promise<T>
      ): Promise<T> {
        const client = new MongoClient(datasource.config!.connectionString)
        await client.connect()
        try {
          return await callback(client)
        } finally {
          await client.close()
        }
      }

      async function withDb<T>(callback: (db: Db) => Promise<T>): Promise<T> {
        return await withClient(async client => {
          return await callback(client.db(datasource.config!.db))
        })
      }

      async function withCollection<T>(
        callback: (collection: Collection) => Promise<T>
      ): Promise<T> {
        return await withDb(async db => {
          return await callback(db.collection(collection))
        })
      }

      beforeAll(async () => {
        const ds = await dsProvider()
        datasource = ds.datasource!
      })

      beforeEach(async () => {
        collection = generator.guid()
        await withCollection(async collection => {
          await collection.insertMany([
            { name: "one" },
            { name: "two" },
            { name: "three" },
            { name: "four" },
            { name: "five" },
          ])
        })
      })

      afterEach(async () => {
        await withCollection(collection => collection.drop())
      })

      describe("preview", () => {
        it("should generate a nested schema with an empty array", async () => {
          const name = generator.guid()
          await withCollection(
            async collection => await collection.insertOne({ name, nested: [] })
          )

          const preview = await config.api.query.preview({
            name: "New Query",
            datasourceId: datasource._id!,
            fields: {
              json: {
                name: { $eq: name },
              },
              extra: {
                collection,
                actionType: "findOne",
              },
            },
            schema: {},
            queryVerb: "read",
            parameters: [],
            transformer: "return data",
            readable: true,
          })

          expect(preview).toEqual({
            nestedSchemaFields: {},
            rows: [{ _id: expect.any(String), name, nested: [] }],
            schema: {
              _id: {
                type: "string",
                name: "_id",
              },
              name: {
                type: "string",
                name: "name",
              },
              nested: {
                type: "array",
                name: "nested",
              },
            },
          })
        })

        it("should update schema when structure changes from object to array", async () => {
          const name = generator.guid()

          await withCollection(async collection => {
            await collection.insertOne({ name, field: { subfield: "value" } })
          })

          const firstPreview = await config.api.query.preview({
            name: "Test Query",
            datasourceId: datasource._id!,
            fields: {
              json: { name: { $eq: name } },
              extra: {
                collection,
                actionType: "findOne",
              },
            },
            schema: {},
            queryVerb: "read",
            parameters: [],
            transformer: "return data",
            readable: true,
          })

          expect(firstPreview.schema).toEqual(
            expect.objectContaining({
              field: { type: "json", name: "field" },
            })
          )

          await withCollection(async collection => {
            await collection.updateOne(
              { name },
              { $set: { field: ["value1", "value2"] } }
            )
          })

          const secondPreview = await config.api.query.preview({
            name: "Test Query",
            datasourceId: datasource._id!,
            fields: {
              json: { name: { $eq: name } },
              extra: {
                collection,
                actionType: "findOne",
              },
            },
            schema: firstPreview.schema,
            queryVerb: "read",
            parameters: [],
            transformer: "return data",
            readable: true,
          })

          expect(secondPreview.schema).toEqual(
            expect.objectContaining({
              field: { type: "array", name: "field" },
            })
          )
        })

        it("should generate a nested schema based on all of the nested items", async () => {
          const name = generator.guid()
          const item = {
            name,
            contacts: [
              {
                address: "123 Lane",
              },
              {
                address: "456 Drive",
              },
              {
                postcode: "BT1 12N",
                lat: 54.59,
                long: -5.92,
              },
              {
                city: "Belfast",
              },
              {
                address: "789 Avenue",
                phoneNumber: "0800-999-5555",
              },
              {
                name: "Name",
                isActive: false,
              },
            ],
          }

          await withCollection(collection => collection.insertOne(item))

          const preview = await config.api.query.preview({
            name: "New Query",
            datasourceId: datasource._id!,
            fields: {
              json: {
                name: { $eq: name },
              },
              extra: {
                collection,
                actionType: "findOne",
              },
            },
            schema: {},
            queryVerb: "read",
            parameters: [],
            transformer: "return data",
            readable: true,
          })

          expect(preview).toEqual({
            nestedSchemaFields: {
              contacts: {
                address: {
                  type: "string",
                  name: "address",
                },
                postcode: {
                  type: "string",
                  name: "postcode",
                },
                lat: {
                  type: "number",
                  name: "lat",
                },
                long: {
                  type: "number",
                  name: "long",
                },
                city: {
                  type: "string",
                  name: "city",
                },
                phoneNumber: {
                  type: "string",
                  name: "phoneNumber",
                },
                name: {
                  type: "string",
                  name: "name",
                },
                isActive: {
                  type: "boolean",
                  name: "isActive",
                },
              },
            },
            rows: [{ ...item, _id: expect.any(String) }],
            schema: {
              _id: { type: "string", name: "_id" },
              name: { type: "string", name: "name" },
              contacts: { type: "json", name: "contacts", subtype: "array" },
            },
          })
        })
      })

      describe("execute", () => {
        it("a count query", async () => {
          const query = await createQuery({
            fields: {
              json: {},
              extra: {
                actionType: "count",
              },
            },
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([{ value: 5 }])
        })

        it("should be able to updateOne by ObjectId", async () => {
          const insertResult = await withCollection(c =>
            c.insertOne({ name: "one" })
          )
          const query = await createQuery({
            fields: {
              json: {
                filter: {
                  _id: { $eq: `ObjectId("${insertResult.insertedId}")` },
                },
                update: { $set: { name: "newName" } },
              },
              extra: {
                actionType: "updateOne",
              },
            },
            queryVerb: "update",
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([
            {
              acknowledged: true,
              matchedCount: 1,
              modifiedCount: 1,
              upsertedCount: 0,
              upsertedId: null,
            },
          ])

          await withCollection(async collection => {
            const doc = await collection.findOne({ name: { $eq: "newName" } })
            expect(doc).toEqual({
              _id: insertResult.insertedId,
              name: "newName",
            })
          })
        })

        it("a count query with a transformer", async () => {
          const query = await createQuery({
            fields: {
              json: {},
              extra: {
                actionType: "count",
              },
            },
            transformer: "return data + 1",
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([{ value: 6 }])
        })

        it("a find query", async () => {
          const query = await createQuery({
            fields: {
              json: {},
              extra: {
                actionType: "find",
              },
            },
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([
            { _id: expectValidId, name: "one" },
            { _id: expectValidId, name: "two" },
            { _id: expectValidId, name: "three" },
            { _id: expectValidId, name: "four" },
            { _id: expectValidId, name: "five" },
          ])
        })

        it("a findOne query", async () => {
          const query = await createQuery({
            fields: {
              json: {},
              extra: {
                actionType: "findOne",
              },
            },
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([{ _id: expectValidId, name: "one" }])
        })

        it("a findOneAndUpdate query", async () => {
          const query = await createQuery({
            fields: {
              json: {
                filter: { name: { $eq: "one" } },
                update: { $set: { name: "newName" } },
              },
              extra: {
                actionType: "findOneAndUpdate",
              },
            },
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([
            {
              lastErrorObject: { n: 1, updatedExisting: true },
              ok: 1,
              value: { _id: expectValidId, name: "one" },
            },
          ])

          await withCollection(async collection => {
            expect(await collection.countDocuments()).toBe(5)

            const doc = await collection.findOne({ name: { $eq: "newName" } })
            expect(doc).toEqual({
              _id: expectValidBsonObjectId,
              name: "newName",
            })
          })
        })

        it("a distinct query", async () => {
          const query = await createQuery({
            fields: {
              json: "name",
              extra: {
                actionType: "distinct",
              },
            },
          })

          const result = await config.api.query.execute(query._id!)
          const values = result.data.map(o => o.value).sort()
          expect(values).toEqual(["five", "four", "one", "three", "two"])
        })

        it("a create query with parameters", async () => {
          const query = await createQuery({
            fields: {
              json: { foo: "{{ foo }}" },
              extra: {
                actionType: "insertOne",
              },
            },
            queryVerb: "create",
            parameters: [
              {
                name: "foo",
                default: "default",
              },
            ],
          })

          const result = await config.api.query.execute(query._id!, {
            parameters: { foo: "bar" },
          })

          expect(result.data).toEqual([
            {
              acknowledged: true,
              insertedId: expectValidId,
            },
          ])

          await withCollection(async collection => {
            const doc = await collection.findOne({ foo: { $eq: "bar" } })
            expect(doc).toEqual({
              _id: expectValidBsonObjectId,
              foo: "bar",
            })
          })
        })

        it("a delete query with parameters", async () => {
          const query = await createQuery({
            fields: {
              json: { name: { $eq: "{{ name }}" } },
              extra: {
                actionType: "deleteOne",
              },
            },
            queryVerb: "delete",
            parameters: [
              {
                name: "name",
                default: "",
              },
            ],
          })

          const result = await config.api.query.execute(query._id!, {
            parameters: { name: "one" },
          })

          expect(result.data).toEqual([
            {
              acknowledged: true,
              deletedCount: 1,
            },
          ])

          await withCollection(async collection => {
            const doc = await collection.findOne({ name: { $eq: "one" } })
            expect(doc).toBeNull()
          })
        })

        it("an update query with parameters", async () => {
          const query = await createQuery({
            fields: {
              json: {
                filter: { name: { $eq: "{{ name }}" } },
                update: { $set: { name: "{{ newName }}" } },
              },
              extra: {
                actionType: "updateOne",
              },
            },
            queryVerb: "update",
            parameters: [
              {
                name: "name",
                default: "",
              },
              {
                name: "newName",
                default: "",
              },
            ],
          })

          const result = await config.api.query.execute(query._id!, {
            parameters: { name: "one", newName: "newOne" },
          })

          expect(result.data).toEqual([
            {
              acknowledged: true,
              matchedCount: 1,
              modifiedCount: 1,
              upsertedCount: 0,
              upsertedId: null,
            },
          ])

          await withCollection(async collection => {
            const doc = await collection.findOne({ name: { $eq: "newOne" } })
            expect(doc).toEqual({
              _id: expectValidBsonObjectId,
              name: "newOne",
            })

            const oldDoc = await collection.findOne({ name: { $eq: "one" } })
            expect(oldDoc).toBeNull()
          })
        })

        it("should be able to delete all records", async () => {
          const query = await createQuery({
            fields: {
              json: {},
              extra: {
                actionType: "deleteMany",
              },
            },
            queryVerb: "delete",
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([
            {
              acknowledged: true,
              deletedCount: 5,
            },
          ])

          await withCollection(async collection => {
            const docs = await collection.find().toArray()
            expect(docs).toHaveLength(0)
          })
        })

        it("should be able to update all documents", async () => {
          const query = await createQuery({
            fields: {
              json: {
                filter: {},
                update: { $set: { name: "newName" } },
              },
              extra: {
                actionType: "updateMany",
              },
            },
            queryVerb: "update",
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([
            {
              acknowledged: true,
              matchedCount: 5,
              modifiedCount: 5,
              upsertedCount: 0,
              upsertedId: null,
            },
          ])

          await withCollection(async collection => {
            const docs = await collection.find().toArray()
            expect(docs).toHaveLength(5)
            for (const doc of docs) {
              expect(doc).toEqual({
                _id: expectValidBsonObjectId,
                name: "newName",
              })
            }
          })
        })

        it("should be able to select a ObjectId in a transformer", async () => {
          const query = await createQuery({
            fields: {
              json: {},
              extra: {
                actionType: "find",
              },
            },
            transformer: "return data.map(x => ({ id: x._id }))",
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([
            { id: expectValidId },
            { id: expectValidId },
            { id: expectValidId },
            { id: expectValidId },
            { id: expectValidId },
          ])
        })

        it("can handle all bson field types with transformers", async () => {
          collection = generator.guid()
          await withCollection(async collection => {
            await collection.insertOne({
              _id: new BSON.ObjectId("65b0123456789abcdef01234"),
              stringField: "This is a string",
              numberField: 42,
              doubleField: new BSON.Double(42.42),
              integerField: new BSON.Int32(123),
              longField: new BSON.Long("9223372036854775807"),
              booleanField: true,
              nullField: null,
              arrayField: [1, 2, 3, "four", { nested: true }],
              objectField: {
                nestedString: "nested",
                nestedNumber: 99,
              },
              dateField: new Date(Date.UTC(2025, 0, 30, 12, 30, 20)),
              timestampField: new BSON.Timestamp({ t: 1706616000, i: 1 }),
              binaryField: new BSON.Binary(
                new TextEncoder().encode("bufferValue")
              ),
              objectIdField: new BSON.ObjectId("65b0123456789abcdef01235"),
              regexField: new BSON.BSONRegExp("^Hello.*", "i"),
              minKeyField: new BSON.MinKey(),
              maxKeyField: new BSON.MaxKey(),
              decimalField: new BSON.Decimal128("12345.6789"),
              codeField: new BSON.Code(
                "function() { return 'Hello, World!'; }"
              ),
              codeWithScopeField: new BSON.Code(
                "function(x) { return x * 2; }",
                { x: 10 }
              ),
            })
          })

          const query = await createQuery({
            fields: {
              json: {},
              extra: {
                actionType: "find",
                collection,
              },
            },
            transformer: `return data.map(x => ({ 
                  ...x,
                  binaryField: x.binaryField?.toString('utf8'),
                  decimalField: x.decimalField.toString(),
                  longField: x.longField.toString(),
                  regexField: x.regexField.toString(),
                  // TODO: currenlty not supported, it looks like there is bug in the library. Getting: Timestamp constructed from { t, i } must provide t as a number
                  timestampField: null
              }))`,
          })

          const result = await config.api.query.execute(query._id!)

          expect(result.data).toEqual([
            {
              _id: "65b0123456789abcdef01234",
              arrayField: [
                1,
                2,
                3,
                "four",
                {
                  nested: true,
                },
              ],
              binaryField: "bufferValue",
              booleanField: true,
              codeField: {
                code: "function() { return 'Hello, World!'; }",
              },
              codeWithScopeField: {
                code: "function(x) { return x * 2; }",
                scope: {
                  x: 10,
                },
              },
              dateField: "2025-01-30T12:30:20.000Z",
              decimalField: "12345.6789",
              doubleField: 42.42,
              integerField: 123,
              longField: "9223372036854775807",
              maxKeyField: {},
              minKeyField: {},
              nullField: null,
              numberField: 42,
              objectField: {
                nestedNumber: 99,
                nestedString: "nested",
              },
              objectIdField: "65b0123456789abcdef01235",
              regexField: "/^Hello.*/i",
              stringField: "This is a string",
              timestampField: null,
            },
          ])
        })
      })

      it("should throw an error if the incorrect actionType is specified", async () => {
        const verbs = ["read", "create", "update", "delete"] as const
        for (const verb of verbs) {
          const query = await createQuery({
            // @ts-expect-error
            fields: { json: {}, extra: { actionType: "invalid" } },
            queryVerb: verb,
          })
          await config.api.query.execute(query._id!, undefined, { status: 400 })
        }
      })

      it("should ignore extra brackets in query", async () => {
        const query = await createQuery({
          fields: {
            json: { foo: "te}st" },
            extra: {
              actionType: "insertOne",
            },
          },
          queryVerb: "create",
        })

        const result = await config.api.query.execute(query._id!)
        expect(result.data).toEqual([
          {
            acknowledged: true,
            insertedId: expectValidId,
          },
        ])

        await withCollection(async collection => {
          const doc = await collection.findOne({ foo: { $eq: "te}st" } })
          expect(doc).toEqual({
            _id: expectValidBsonObjectId,
            foo: "te}st",
          })
        })
      })

      it("should be able to save deeply nested data", async () => {
        const data = {
          foo: "bar",
          data: [
            { cid: 1 },
            { cid: 2 },
            {
              nested: {
                name: "test",
                ary: [1, 2, 3],
                aryOfObjects: [{ a: 1 }, { b: 2 }],
              },
            },
          ],
        }
        const query = await createQuery({
          fields: {
            json: data,
            extra: {
              actionType: "insertOne",
            },
          },
          queryVerb: "create",
        })

        const result = await config.api.query.execute(query._id!)
        expect(result.data).toEqual([
          {
            acknowledged: true,
            insertedId: expectValidId,
          },
        ])

        await withCollection(async collection => {
          const doc = await collection.findOne({ foo: { $eq: "bar" } })
          expect(doc).toEqual({
            _id: expectValidBsonObjectId,
            ...data,
          })
        })
      })
    }
  )
}
